Run Rails API on Azure container instances

Azure is a fairly new cloud environment for me. Getting an API up and running on container instances posed a bit of a challenge. So here I am sharing my approach to automate and deploy container groups in Azure.

Things to do

  1. Build a Rails API
  2. Containerise API
  3. Push API container to Azure container repository from Github
  4. Deploy API container to Azure container group using ARM templates
  5. Secrets should be stored in Azure key vault

Build a Rails API

There are plenty of tutorials out there that clearly outlay how to build a Rails API. It is as simple as running the following in your terminal.

rails new my_api --api

Containerise API

Here is a sample Dockerfile for a Rails API.

# set OS
FROM ubuntu:bionic

# Set Ruby
FROM ruby:2.7.2

RUN gem install bundler -v 2.0

# create api directory
RUN mkdir /my_api

WORKDIR /my_api

# copy gemfile
COPY Gemfile /my_api/Gemfile

COPY Gemfile.lock /my_api/Gemfile.lock 

# install gems
RUN bundle install 

# copy app files
COPY . /my_api

EXPOSE 80

CMD ["bundle", "exec", "puma", "-C", "config/puma.rb", "-p", "80"]

Push API container to Azure container repository

This step is automated via Github workflows. Azure container registry is a prerequisite for this step.

  • Allow Github to access Azure container registry

Create a service principal for Azure authentication via Azure cli. A handy tutorial is available here

  • Set secrets in Github repository

AZURE_CREDENTIALS – The entire JSON response when you created RBAC in the previous step.

REGISTRY_LOGIN_SERVER – Name of your registry.

REGISTRY_USERNAME – The client ID you can find from the credentials JSON.

REGISTRY_PASSWORD – The client secret from the credentials JSON.

The following workflow yaml pushes API container to a repository named my_api to Azure registry tagged latest commit sha.

name: acr_registry_build
on:
  push:
    branches:
      - master
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    # checkout the repo
    - name: 'Checkout GitHub Action'
      uses: actions/checkout@main
      
    - name: 'Login via Azure CLI'
      uses: azure/login@v1
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}
    
    - name: 'Build and push image'
      uses: azure/docker-login@v1
      with:
        login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }}
        username: ${{ secrets.REGISTRY_USERNAME }}
        password: ${{ secrets.REGISTRY_PASSWORD }}
    - run: |
        docker build -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/my_api:${{ github.sha }} -f Dockerfile .
        docker push ${{ secrets.REGISTRY_LOGIN_SERVER }}/my_api:${{ github.sha }}

Deploy API container to Azure container group

Once the container is ready in container registry, it can now be deployed. Deployment automation is handled as ARM templates. Any secrets the API requires in its environment are stored in Azure key vault.

Github workflow yaml for deployment. Two additional secrets Azure Subscription ID and Resource Group need to be added to Github. Provide ARM template and parameters file along with additional inline parameters to arm-deploy action.

name: api_deployment
on: workflow_dispatch
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    # checkout the repo
    - name: 'Checkout GitHub Action'
      uses: actions/checkout@main
      
    - name: 'Login via Azure CLI'
      uses: azure/login@v1
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}

    - name: 'Deploy to Azure'
      uses: azure/arm-deploy@v1
      with:
        subscriptionId: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
        resourceGroupName: ${{ secrets.RESOURCE_GROUP }}
        template: my-api-deployment.json
        parameters: parameters.json image=${{ secrets.REGISTRY_LOGIN_SERVER }}/my_api:${{ github.sha }} registryLoginServer=${{ secrets.REGISTRY_LOGIN_SERVER }}

ARM template to deploy API container to container groups. (my-api-deployment.json referenced in Github workflow yaml )

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "vnetName": {
      "type": "string",
      "defaultValue": "MyApiVNet",
      "metadata": {
        "description": "VNet name"
      }
    },
    "subnetName": {
      "type": "string",
      "defaultValue": "MyApiSubnet",
      "metadata": {
        "description": "Subnet name"
      }
    },
    "location": {
      "type": "string",
      "defaultValue": "australiaeast",
      "metadata": {
        "description": "Location for all resources."
      }
    },
    "containerGroupName": {
      "type": "string",
      "defaultValue": "my-api-containergroup",
      "metadata": {
        "description": "Container group name"
      }
    },
    "containerName": {
      "type": "string",
      "defaultValue": "my-api-container",
      "metadata": {
        "description": "Container name"
      }
    },
    "image": {
      "type": "string",
      "metadata": {
        "description": "Container image to deploy. Should be of the form accountName/imagename:tag for images stored in Docker Hub or a fully qualified URI for a private registry like the Azure Container Registry."
      },
      "defaultValue": ""
    },
    "port": {
      "type": "string",
      "metadata": {
        "description": "Port to open on the container."
      },
      "defaultValue": "80"
    },
    "cpuCores": {
      "type": "string",
      "metadata": {
        "description": "The number of CPU cores to allocate to the container. Must be an integer."
      },
      "defaultValue": "1.0"
    },
    "memoryInGb": {
      "type": "string",
      "metadata": {
        "description": "The amount of memory to allocate to the container in gigabytes."
      },
      "defaultValue": "1.5"
    },
    "registryLoginServer": {
      "type": "string"
    },
    "registryUsername": {
      "type": "string"
    },
    "registryPassword": {
      "type": "string"
    },
    "clientApiAccessKey": {
      "type": "securestring"
    },
    "railsMasterKey": {
      "type": "securestring"
    }
  },
  "variables": {
    "networkProfileName": "my-api-networkProfile",
    "interfaceConfigName": "eth0",
    "interfaceIpConfig": "ipconfigprofile1"
  },
  "resources": [
    {
      "name": "[variables('networkProfileName')]",
      "type": "Microsoft.Network/networkProfiles",
      "apiVersion": "2020-05-01",
      "location": "[parameters('location')]",
      "properties": {
        "containerNetworkInterfaceConfigurations": [
          {
            "name": "[variables('interfaceConfigName')]",
            "properties": {
              "ipConfigurations": [
                {
                  "name": "[variables('interfaceIpConfig')]",
                  "properties": {
                    "subnet": {
                      "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('subnetName'))]"
                    }
                  }
                }
              ]
            }
          }
        ]
      }
    },
    {
      "name": "[parameters('containerGroupName')]",
      "type": "Microsoft.ContainerInstance/containerGroups",
      "apiVersion": "2019-12-01",
      "location": "[parameters('location')]",
      "dependsOn": [
        "[resourceId('Microsoft.Network/networkProfiles', variables('networkProfileName'))]"
      ],
      "properties": {
        "containers": [
          {
            "name": "[parameters('containerName')]",
            "properties": {
              "image": "[parameters('image')]",
              "ports": [
                {
                  "port": "[parameters('port')]",
                  "protocol": "Tcp"
                }
              ],
              "environmentVariables": [
                {
                  "name": "CLIENT_API_ACCESS_KEY",
                  "secureValue": "[parameters('clientApiAccessKey')]"
                },
                {
                  "name": "RAILS_ENV",
                  "value": "production"
                },
                {
                  "name": "RAILS_MASTER_KEY",
                  "secureValue": "[parameters('railsMasterKey')]"
                }
              ],
              "resources": {
                "requests": {
                  "cpu": "[parameters('cpuCores')]",
                  "memoryInGB": "[parameters('memoryInGb')]"
                }
              }
            }
          }
        ],
        "imageRegistryCredentials": [
          {
            "server": "[parameters('registryLoginServer')]" ,
            "username": "[parameters('registryUsername')]",
            "password": "[parameters('registryPassword')]"
          }
        ],
        "osType": "Linux",
        "networkProfile": {
          "id": "[resourceId('Microsoft.Network/networkProfiles', variables('networkProfileName'))]"
        },
        "restartPolicy": "Always"
      }
    }
  ],
  "outputs": {
    "containerIPv4Address": {
      "type": "string",
      "value": "[reference(resourceId('Microsoft.ContainerInstance/containerGroups/', parameters('containerGroupName'))).ipAddress.ip]"
    }
  }
}

Parameters file defining key vault access. ( parameters.json referenced in Github workflow yaml)

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
      "clientApiAccessKey": {
        "reference": {
          "keyVault": {
          "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyApiAUEastRG/providers/Microsoft.KeyVault/vaults/MyApiKeyVault"
          },
          "secretName": "clientApiAccessKey"
        }
      },
      "registryUsername": {
        "reference": {
          "keyVault": {
          "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyApiAUEastRG/providers/Microsoft.KeyVault/vaults/MyApiKeyVault"
          },
          "secretName": "registryUsername"
        }
      },
      "registryPassword": {
        "reference": {
          "keyVault": {
          "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyApiAUEastRG/providers/Microsoft.KeyVault/vaults/MyApiKeyVault"
          },
          "secretName": "registryPassword"
        }
      },
      "railsMasterKey": {
        "reference": {
          "keyVault": {
          "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyApiAUEastRG/providers/Microsoft.KeyVault/vaults/MyApiKeyVault"
          },
          "secretName": "railsMasterKey"
        }
      }
  }
}

Azure container group can be wired to Azure app gateway as a backend pool. SSL and other security enforcements can be done at app gateway end.

References

https://docs.microsoft.com/en-us/azure/azure-resource-manager/templates/key-vault-parameter?tabs=azure-cli

https://docs.microsoft.com/en-us/azure/container-instances/container-instances-github-action